Enfin tout comprendre des imports

Pour en finir avec ces erreurs

Julien Lenormand - Kaizen Solutions

Introduction

39 slides (non-numérotées)

TODO: vocabulaire module/package

Présentation du speaker

Julien Lenormand

Ingénieur informatique

Pythonista depuis ~2009

Problèmes récurrents

  • pas la bonne version de python
  • ça marche chez moi, mais pas sur la CI
    • ou inversement
  • pas les bonnes versions des librairies
  • module toto not found
  • dépendances “partagées” entre plusieurs projets
  • build pas reproductible
  • AttributeError: module 'math' has no attribute 'pi'
  • les tests n’arrivent pas à importer le code appli

Le nœud du problème

Les imports et donc le Path

Objectif : tout comprendre !

Comment comprendre ?

  • les problèmes sur les projets pros
  • répondre à des inconnus sur StackOverflow
  • lire la documentation (ce que j’ai fait pour ce talk)
  • partager de bonnes ressources
  • assister à des talks
    • en donner, par exemple les Human Talks

On commence ?

Bref historique

  • Python 2 :
  • aaaargh!
  • Python 3 :
  • changement par rapport à Python 2
  • nombreuses PEPs (voir fin)

Reprenons depuis le début

Syntaxes

The Python langage reference, The import statement

  • import toto
  • va importer le module toto et le bind dans le module actuel
  • autrement dit, c’est : search via __import__ + bind
  • on peut le réécrire : toto = __import__("toto") (grosse généralisation)

. . .

  • import toto as tata c’est tata = __import__("toto")
  • le as sert juste à binder sur un nom différent (pratique !)
  • import numpy as np
  • from toto import titi, tutu c’est
  • _temp = __import__("toto", fromlist=["titi", "tutu"])
    titi = _temp.titi
    tutu = _temp.tutu

. . .

  • from toto import * c’est
  • _temp = __import__("toto")
    for _name in _temp.__all__:
    setattr(current_module, x, 
    getattr(_temp, x))
  • gotta bind’em all !

On plonge !

Objectif

La machinerie

def import_module(name, package=None):
    """An approximate implementation of import."""
    absolute_name = importlib.util.resolve_name(name, package)
    try:
        return sys.modules[absolute_name]
    except KeyError:
        pass
    path = None
    if '.' in absolute_name:
        parent_name, _, child_name = absolute_name.rpartition('.')
        parent_module = import_module(parent_name)
        path = parent_module.__spec__.submodule_search_locations
    for finder in sys.meta_path:
        spec = finder.find_spec(absolute_name, path)
        if spec is not None:
            break
    else:
        msg = f'No module named {absolute_name!r}'
        raise ModuleNotFoundError(msg, name=absolute_name)
    module = importlib.util.module_from_spec(spec)
    sys.modules[absolute_name] = module
    spec.loader.exec_module(module)
    if path is not None:
        setattr(parent_module, child_name, module)
    return module

Imports relatifs et absolus

PEP 328 – Imports: Multi-Line and Absolute/Relative (2004)

  • A quoi ça ressemble ?
    • from .titi import tutu
  • Comment convertir de relatif vers absolu ?
    • on remonte autant de dossiers parents que de points préfixes, puis on réalise le from-import normalement
  • Exemple :
    • root/
      • toto/
        • tata.py
      • titi/
        • tutu.py
    • Si j’ai toto/tata.py qui fait from ..titi import tutu
    • Alors ça revient à faire import titi.tutu

Le cache de modules

  • Python regarde dans sys.modules si "toto" est présent
    • si oui, c’est (presque) terminé, il ne reste qu’à binder
    • si non, il va devoir aller le chercher
  • On peut l’inspecter :
    • print('\n'.join(
      sorted(sys.modules.keys())
      ))
  • mieux vaut ne pas le modifier (par mégarde) !

Trouver le module

  • Python a une liste de finders qui sont successivement sollicités pour fournir une ModuleSpec :
    • d’abord les sys.meta_path finders, qui ne se basent pas sur le sys.path
    • puis les sys.path_hooks finders qui se basent sur le sys.path
    • sinon ModuleNotFoundError

Les meta-path finders

Interface :

importlib.abc.MetaPathFinder

class MetaPathFinder:
  find_spec(fullname, path, target=None) -> Optional[ModuleSpec]

Ceux par défaut pour CPython :

>>> sys.meta_path
[<class '_frozen_importlib.BuiltinImporter'>,
 <class '_frozen_importlib.FrozenImporter'>,
 <class '_frozen_importlib_external.PathFinder'>]

Meta-path finder : BuiltinImporter

  • en charge des modules builtins hardcodés (en C) dans l’interpréteur CPython
  • ```python
>>> sys.builtin_module_names
('_abc', '_ast', '_codecs', '_collections', '_functools', '_imp', '_io',
'_locale', '_operator', '_signal', '_sre', '_stat', '_string', '_symtable',
'_thread', '_tracemalloc', '_warnings', '_weakref', 'atexit', 'builtins',
'errno', 'faulthandler', 'gc', 'itertools', 'marshal', 'posix', 'pwd', 'sys',
'time', 'xxsubtype')
```

Meta-path finder : FrozenImporter

Meta-path finder : le PathFinder

  • celui que vous utilisez sans le savoir 95% du temps
  • il délègue simplement aux path-based finders, qu’il appelle successivement pour trouver le fichier du module

Les path-based finders

Interface :

importlib.abc.PathEntryFinder

class PathEntryFinder:
  find_spec(fullname, target=None) -> Optional[ModuleSpec]

Ceux par défaut pour CPython :

>>> sys.path_hooks
[<class 'zipimport.zipimporter'>,
 <function FileFinder.path_hook.<locals>.path_hook_for_FileFinder at 0x000001D373940FE0>]

Les ModuleSpec

  • toutes les informations sur comment importer un module
  • observable via toto.__spec__
  •   >>> import sys
      >>> sys.__spec__
      ModuleSpec(name='sys',
                 loader=<class '_frozen_importlib.BuiltinImporter'>,
                 origin='built-in')`
      >>> import toto.tutu
      >>> toto.tutu.__spec__
      ModuleSpec(name='toto.tutu',
                 loader=<_frozen_importlib_external.SourceFileLoader object at 0x00000225EF0ABD10>
                 origin='/data/talk-import/toto/tutu.py')

Les loaders

Les importers

Objects that implement both of these interfaces are referred to as importers - they return themselves when they find that they can load the requested module.

  • BuiltinImporter
  • FrozenImporter
  • zipimporter

le retour de l’algo

def import_module(name, package=None):
    """An approximate implementation of import."""
    absolute_name = importlib.util.resolve_name(name, package)
    try:
        return sys.modules[absolute_name]
    except KeyError:
        pass
    path = None
    if '.' in absolute_name:
        parent_name, _, child_name = absolute_name.rpartition('.')
        parent_module = import_module(parent_name)
        path = parent_module.__spec__.submodule_search_locations
    for finder in sys.meta_path:
        spec = finder.find_spec(absolute_name, path)
        if spec is not None:
            break
    else:
        msg = f'No module named {absolute_name!r}'
        raise ModuleNotFoundError(msg, name=absolute_name)
    module = importlib.util.module_from_spec(spec)
    sys.modules[absolute_name] = module
    spec.loader.exec_module(module)
    if path is not None:
        setattr(parent_module, child_name, module)
    return module

Fini ?

Non.

On remonte à la surface

  • A quoi servait cet énorme détour ?
  • Il nous reste à répondre aux questions :
    • à quoi sert le __init__ d’un package ?
    • où cherchent les path-based finders ?

A quoi sert l’__init__.py d’un package

  • A rien. Optionnel depuis PEP-420 : PEP 420 – Implicit Namespace Packages (2012)
  • A plein de choses :
    • indiquer que c’est un regular package (pour les humains ou la perf)
    • initialisation (d’où son nom)
      • garantie d’exécution unique, centrale et antérieure à tous ses subpackages
    • visibilité : __all__
      • permet de ramener tous les symboles au même endroit, et ainsi permettre un simple import toto qui importe tout ce qui est pertinent, plutôt que de devoir chasser dans les subpackages

Où cherchent les path-based finders ?

  • où vous voulez !
  • sys.path
  • The initialization of the sys.path module search path :
    1. input script directory : python path/to/file.py -> /abs/path/to/
      or working directory : python -m path.to.file -> /abs/
    2. PYTHONPATH si défini
    3. std lib de l’interpréteur
      • /usr/lib/python38.zip (dans un fichier zip !)
    4. third-party libraries
      • system : /usr/lib/python3.8/site-packages
      • ou venv : /home/talk/python-import/venv/lib/site-packages
  • Override via : site, pyvenv.cfg, PYTHONHOME, virtual environments, _pth files
  • attention au sys.path_importer_cache
  • modification au runtime de sys.path (une liste) : append/prepend

La fin des problèmes ?

Problèmes récurrents

  1. pas la bonne version de python
  2. ça marche chez moi, mais pas sur la CI, ou inversement
  3. pas les bonnes versions des librairies
  4. module toto not found
  5. dépendances “partagées” entre plusieurs projets
  6. build pas reproductible
  7. AttributeError: module 'math' has no attribute 'pi'
  8. les tests n’arrivent pas à importer le code appli
  1. utiliser la version de Python voulue pour créer un venv puis l’utiliser
  2. problème à creuser, mais une piste c’est de comparer ce qui est fait différement
  3. utiliser un venv, et si besoin locker (surtout les dépendances transitives)
  4. inspecter le sys.path et comparer par rapport à où est installé le module
  5. ne pas faire ça, isoler avec des venv
  6. locker les dépendances, sinon docker
  7. éviter le shadowing, sinon ré-ordonner le sys.path
  8. set le PYTHONPATH pour contenir la racine des sources du projet
  9. slides suivante !

Mes conseils

  • Pour ne pas avoir de problème :
    • avoir un package top-level avec un nom unique
    • utiliser des imports absolus tant que possible, et éviter de mélanger (doublons de modules)
    • éliminer les utilisations de sys.path.(ap/pre)pend
    • éviter les imports récursifs (moins vrai sur les CPython modernes)
    • configurer le path dans PyCharm : settings du projet, settings de la run config
    • créer un venv avec la bonne version de python, et isolé du système
      (par défaut, sauf en cas de --system-site-packages)
    • sur Windows : utiliser la commande py et les venv
    • sur Linux : toujours des venv pour ne pas toucher au Python système
    • dans Docker, ça dépend, on peut se passer d’un venv
    • éviter importlib.reload sauf TRES bonne raison
    • se méfier de la création d’une copie lors d’un from-import versus import d’une variable mutable
    • éviter les IO au top-level dans les modules (préférer une initialisation paramétrée ?)
  • En cas de problèmes quand même :

Epilogue

Quelques petits imports drôles

  • import this
  • import antigravity
  • from __future__ import braces

Conclusion

  • mécanisme central (la force de Python ce sont ses librairies)
  • extensible et puissant
  • modérément observable mais difficle à débugger
  • méconnu, surtout le rôle de sys.path

Les 4 piliers des problèmes d’import :

  1. par défaut, le singleton “python système” est utilisé
    • ne le faites pas, utilisez des venv !
  2. méconnaissance du système d’import
    • vous ne pouvez plus vous prétendre 100% ignorant
  3. manque de suivi des conventions / bonnes pratiques
    • je vous en ai donné plein
  4. mauvaise hygiène des dépendances
    • ça reste un problème, pour un prochain Talk ?

Sources

Pour aller plus loin

Questions

?

Julien Lenormand

Dev et responsable du Pôle Software

julien.lenormand@kaizen-solutions.net

Apéro !